Skip to content

[PoC] Discover profile state#271164

Draft
davismcphee wants to merge 5 commits into
elastic:mainfrom
davismcphee:poc-profile-state
Draft

[PoC] Discover profile state#271164
davismcphee wants to merge 5 commits into
elastic:mainfrom
davismcphee:poc-profile-state

Conversation

@davismcphee
Copy link
Copy Markdown
Contributor

@davismcphee davismcphee commented May 26, 2026

Summary

Introduces an extensible state-management layer that lets Discover profiles own state with different lifetimes — ephemeral UI, URL-synced, locally persisted, and locator-embedded — without each profile having to wire its own plumbing into the tab/Redux/URL stack.

Relates to #242987.

Motivation

Profiles like Metrics need state that survives tab switches, page refreshes, shared URLs, and locator links (dimension breakdowns, chart configs, view-mode toggles, etc.). Today the only generic slots are appState / globalState, which don't fit ad-hoc profile shapes and don't expose lifetime distinctions. The goal is one declarative contract a profile can describe once, with a single host-side registry that routes each field to the right destination.

Approach

A profile declares the shape of its state once, annotates each field with a lifetime ("type"), and reads/writes it through a per-tab adapter. The host inspects those descriptors to route state to Redux, the URL, local storage, and the locator. Profiles never touch any of those layers directly.

Key building blocks

  • ProfileStateDefinition<TState>{ key, descriptor } where descriptor is { [field]: { type: 'ui' | 'url' | 'persistent' } }. key namespaces this state across profiles in the tab's profileState record. (profile_state.ts)
  • ProfileStateRegistry — Single registry on services.profileStateRegistry. Definitions register once at plugin start via registerProfileStateDefinitions. pickStateByType is what every destination uses to filter the right fields out.
  • ProfileStateAdapter<TState> — The simplified interface profiles use to read and write their state. It deliberately hides Redux, observables wiring, URL syncing, and storage from profile code, which means:
    • Profiles can be hosted outside Discover Main — the host (an embeddable, the Surrounding Docs view, a future runtime, etc.) supplies its own adapter implementation, and the same profile code keeps working.
    • State is strongly typed and customizable — each definition declares its own TState, so there's no fixed schema profiles must conform to.
    • State isn't tied to a specific profile — a single definition can be consumed across context levels; a root, data source, and document profile can all call toolkit.getStateAdapter(MY_DEF) and read/write the same state.
  • ContextAwarenessToolkit.getStateAdapter — The already-existing toolkit argument on extension points. Returns the adapter scoped to the current tab and definition.
  • DataSourceContext.profileState — A data-source profile binds itself to a definition by returning profileState: MY_DEF from its resolve context. URL sync, locator emission, and restoration all key off this binding to know which definition is currently active for a tab.

How each lifetime flows

Type Where it lives Mechanism
Ui Tab Redux state only Never serialized.
Url URL _p query param ⇄ Redux Applied on init from _p and two-way synced with the URL while the bound profile is active.
Persistent Local storage ⇄ Redux Filtered via getPersistentProfileState on write and reload (tabs_storage_manager.ts).
Locator DiscoverAppLocatorParams.profileUrlState Produced by getProfileUrlState; consumed by the share menu, search-session restoration, and appLocatorGetLocationCommon (which writes it to _p).

Trying it out

  1. Enable the example profiles in kibana.dev.yml:
    discover.experimental.enabledProfiles:
      - example-root-profile
      - example-data-source-profile
      - example-document-profile
  2. Install the Sample web logs dataset via Add sample data.
  3. Add an alias so the example profile resolves against the sample data. In Dev Tools:
    POST _aliases
    {
      "actions": [
        { "add": { "index": "kibana_sample_data_logs", "alias": "my-example-logs" } }
      ]
    }
    
  4. In Discover, switch to ES|QL and run FROM my-example-logs. Expand any row → Extensible State Example doc-viewer tab. The three selects exercise UI, Persistent, and URL state respectively.

Examples

1. The adapter interface

interface ProfileStateAdapter<TState extends object> {
  getState: () => TState;
  getState$: () => Observable<TState>;
  setState: (state: TState, options?: ProfileStateMutationOptions) => void;
  updateState: (stateUpdate: Partial<TState>, options?: ProfileStateMutationOptions) => void;
}

interface ProfileStateMutationOptions {
  // Push a new URL history entry (default) or replace the current one.
  historyMethod?: 'push' | 'replace';
}

2. Declare state

export interface ColorState {
  timestampColor: string;                                           // UI
  rowControlColor: 'neutral' | 'primary' | 'success' | 'danger';    // Persistent
  boxColor: 'transparent' | 'success' | 'warning' | 'danger';       // URL
}

export const COLOR_STATE_DEF: ProfileStateDefinition<ColorState> = {
  key: 'colorState',
  descriptor: {
    timestampColor: { type: ProfileStateType.Ui },
    rowControlColor: { type: ProfileStateType.Persistent },
    boxColor: { type: ProfileStateType.Url },
  },
};

export const registerProfileStateDefinitions = (registry: ProfileStateRegistry) => {
  registry.registerDefinition(COLOR_STATE_DEF);
};

registerProfileStateDefinitions is called once during plugin start (plugin.tsx), so a new definition only has to be added there.

3. Bind state to a profile

Setting profileState on a data source profile's resolved context tells the URL sync layer which definition's Url-typed fields to two-way sync with _p while this profile is the active match for the tab. This is the only place a definition gets tied to a specific profile, and only for URL syncing — any state can still be consumed from any profile without a binding.

const provider: DataSourceProfileProvider<MyContext, ColorState> = {
  profileId: 'example-data-source-profile',
  profile: { /* extension points */ },
  resolve: (params) => ({
    isMatch: true,
    context: {
      category: DataSourceCategory.Logs,
      profileState: COLOR_STATE_DEF,
      // ...other context...
    },
  }),
};

4. Consume the same state across profile levels

Any profile — root, data source, or document — can pick up a definition through its toolkit and interact with it. This is useful when state needs to drive behavior across the whole hierarchy (e.g. a setting the chart, row controls, and cell renderers should all react to). The call site is identical regardless of the profile level:

getCellRenderers: (prev, { toolkit }) => {
  const adapter = toolkit.getStateAdapter(COLOR_STATE_DEF);
  const colorState$ = adapter.getState$();

  const Timestamp = (props) => {
    const { timestampColor } = useObservable(colorState$, adapter.getState());
    return <EuiBadge color={timestampColor}>{props.value}</EuiBadge>;
  };

  return (params) => ({ ...prev(params), '@timestamp': Timestamp });
},

5. Mutate state

adapter.updateState({ boxColor: 'warning' });                                  // shallow merge
adapter.updateState({ boxColor: 'warning' }, { historyMethod: 'replace' });    // skip a history entry

adapter.setState({                                                              // full replace
  timestampColor: 'neutral',
  rowControlColor: 'neutral',
  boxColor: 'transparent',
});

Not yet covered

  • Saved-object persistence — the issue's 5th lifetime is not in ProfileStateType yet; persisting profile state into the Discover Session saved object is TBD.
  • historyMethod — accepted by the adapter API but not yet honored by the URL sync path.

Checklist

  • Any text added follows EUI's writing guidelines, uses sentence case text and includes i18n support
  • Documentation was added for features that require explanation or tutorials
  • Unit or functional tests were updated or added to match the most common scenarios
  • If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the docker list
  • This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The release_note:breaking label should be applied in these situations.
  • Flaky Test Runner was used on any tests changed
  • The PR description includes the appropriate Release Notes section, and the correct release_note:* label is applied per the guidelines
  • Review the backport guidelines and apply applicable backport:* labels.

@davismcphee davismcphee self-assigned this May 26, 2026
@davismcphee davismcphee added the Team:DataDiscovery Discover, search (data plugin and KQL), data views, saved searches. For ES|QL, use Team:ES|QL. t// label May 26, 2026
@infra-vault-gh-plugin-prod
Copy link
Copy Markdown

🤖 Jobs for this PR can be triggered through checkboxes. 🚧

ℹ️ To trigger the CI, please tick the checkbox below 👇

  • Click to trigger kibana-pull-request for this PR!
  • Click to trigger kibana-deploy-project-from-pr for this PR!
  • Click to trigger kibana-deploy-cloud-from-pr for this PR!
  • Click to trigger kibana-entity-store-performance-from-pr for this PR!
  • Click to trigger kibana-storybooks-from-pr for this PR!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Team:DataDiscovery Discover, search (data plugin and KQL), data views, saved searches. For ES|QL, use Team:ES|QL. t//

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant